/*
 * ShapeGeometryExporter.java
 *
 * Created on March 28, 2007, 10:23 PM
 *
 */

package ika.geoexport;

import ika.utils.LittleEndianOutputStream;
import ika.geo.GeoObject;
import ika.geo.GeoPath;
import ika.geo.GeoPoint;
import ika.geo.GeoSet;
import java.awt.geom.Rectangle2D;
import java.io.BufferedOutputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import ika.utils.MixedEndianDataOutputStream;
import java.awt.geom.PathIterator;
import java.io.FileOutputStream;
import java.util.ArrayList;

/**
 * Exports a GeoSet to .shp and .shx files. Does not create a .dbf file. 
 * @author Bernhard Jenny, Institute of Cartography, ETH Zurich
 */
public class ShapeGeometryExporter extends GeoSetExporter{

    /**
     * Write points.
     */
    public static final int POINT_SHAPE_TYPE = 1;
    
    /**
     * Write open polylines.
     */
    public static final int POLYLINE_SHAPE_TYPE = 3;
    
    /**
     * Write closed polygons.
     */
    public static final int POLYGON_SHAPE_TYPE = 5;
    
    /**
     * This type of shapes will be exported.
     */
    private int shapeType = POLYLINE_SHAPE_TYPE;
    
    /**
     * Count the exported shapes, start counting at 1. This is needed to 
     * sequentially number the records written to the file.
     */
    private int recordCounter = 1;
    
    /**
     * Store the beginning of each record in this array. This is an array of
     * offsets in bytes counted from the end of the file header. This 
     * information is needed to generate the shx file, which is required by the
     * specification.
     */
    private ArrayList shxRecords = new ArrayList();
    
    
    /** Creates a new instance of ShapeGeometryExporter */
    public ShapeGeometryExporter(double mapScale) {
        super (mapScale);
    }
    
    public String getFileFormatName() {
        return "Shape";
    }
    
    public String getFileExtension() {
        return "shp";
    }

    /**
     * Export a GeoSet to a file.
     * @param geoSet The GeoSet to export.
     * @param filePath A path to a file that will receive the result. If the file
     * already exists, its content is completely overwritten. If the file does
     * not exist, a new file is created.
     */
    public final void export (GeoSet geoSet, String filePath) throws IOException {

        if (geoSet == null || filePath == null)
            throw new IllegalArgumentException();

        OutputStream outputStream = null;
        try {
            outputStream = new FileOutputStream(filePath);
            this.export(geoSet, outputStream);
        } finally {
            if (outputStream != null)
                outputStream.close();
        }

    }

    protected void export(GeoSet geoSet, OutputStream outputStream) throws IOException {
        
        this.recordCounter = 1;
        this.shxRecords.clear();
        
        // Accumulate the data in a ByteArrayOutputStream.
        // This allows for finding the size of the resulting file.
        ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
        BufferedOutputStream buffOs = new BufferedOutputStream(byteArrayOutputStream);
        MixedEndianDataOutputStream geom = new MixedEndianDataOutputStream(buffOs);
        this.writeGeoSet(geom, geoSet);
        // add total size of geometry to shx records
        this.shxRecords.add(new Integer(geom.size()));
        
        // Close the ByteArrayOutputStream.
        // This is not closing the destination outputStream
        geom.close();
        
        // write file header
        MixedEndianDataOutputStream head = new MixedEndianDataOutputStream(outputStream);
        this.writeHeader(geoSet, head, geom.size());
        
        // copy the geometry to the outputStream
        byteArrayOutputStream.writeTo(outputStream);
    }

    /**
     * Writes the file header.
     * @param geoSet The GeoSet to export.
     * @param mos The stream to write to.
     * @dataSize The header contains a file length field. dataSize is in bytes,
     * not including the header size.
     */
    private void writeHeader(GeoSet geoSet, 
            MixedEndianDataOutputStream mos,
            int dataSize)
    throws IOException {
        
        Rectangle2D bbox = getExportedExtension(geoSet);
        
        mos.writeInt(9994);                 // file code
        for (int i = 0; i < 5; i++)         // unused
            mos.writeInt(0);
        mos.writeInt(dataSize / 2 + 50);    // file length
        
        mos.writeLittleEndianInt(1000);     // version
        mos.writeLittleEndianInt(this.shapeType);       // shape type
        mos.writeLittleEndianDouble(bbox.getMinX());    // xmin
        mos.writeLittleEndianDouble(bbox.getMinY());    // ymin
        mos.writeLittleEndianDouble(bbox.getMaxX());    // xmax
        mos.writeLittleEndianDouble(bbox.getMaxY());    // ymax
        mos.writeLittleEndianDouble(0);     // zmin
        mos.writeLittleEndianDouble(0);     // zmax
        mos.writeLittleEndianDouble(0);     // mmin
        mos.writeLittleEndianDouble(0);     // mmax
        
    }
    
    /**
     * Writes a record header. Assigns a unique id to the new record.
     * @param mos The destination stream.
     * @param length the length of the record content in bytes.
     */
    private void writeRecordHeader(MixedEndianDataOutputStream mos, int length)
    throws IOException {
        mos.writeInt(recordCounter++);      // record number, starting at 1
        mos.writeInt(length / 2);           // content length in 16 bit words
    }
    
    /**
     * Writes a GeoSet to a stream.
     */
    private void writeGeoSet(MixedEndianDataOutputStream mos, GeoSet geoSet)
    throws IOException {
        if (geoSet.isVisible() == false)
            return;
        
        final int numberOfChildren = geoSet.getNumberOfChildren();
        for (int i = 0; i < numberOfChildren; i++) {
            
            GeoObject geoObject = geoSet.getGeoObject(i);
            
            // only write visible objects
            if (geoObject.isVisible() == false) {
                continue;
            }
            
            if (geoObject instanceof GeoPath 
                    && (shapeType == POLYGON_SHAPE_TYPE
                    || shapeType == POLYLINE_SHAPE_TYPE)) {
                GeoPath geoPath = (GeoPath)geoObject;
                if (!geoPath.hasOneOrMorePoints())
                    continue;
                shxRecords.add(new Integer(mos.size()));
                writePolyline(mos, geoPath);
            } else if (geoObject instanceof GeoPoint && shapeType == POINT_SHAPE_TYPE) {
                shxRecords.add(new Integer(mos.size()));
                writePoint(mos, (GeoPoint)geoObject);
            } else if (geoObject instanceof GeoSet) {
                this.writeGeoSet(mos, (GeoSet)geoObject);
            }
        }
    }

    public Rectangle2D getExportedExtension (GeoSet geoSet) {
        if (geoSet.isVisible() == false) {
            return new Rectangle2D.Double();
        }

        double minX = Double.MAX_VALUE;
        double maxX = -Double.MAX_VALUE;
        double minY = Double.MAX_VALUE;
        double maxY = -Double.MAX_VALUE;

        final int numberOfChildren = geoSet.getNumberOfChildren();
        for (int i = 0; i < numberOfChildren; i++) {

            GeoObject geoObject = geoSet.getGeoObject(i);

            // only write visible objects
            if (geoObject.isVisible() == false) {
                continue;
            }

            Rectangle2D bbox = null;
            if (geoObject instanceof GeoPath
                    && (shapeType == POLYGON_SHAPE_TYPE
                    || shapeType == POLYLINE_SHAPE_TYPE)) {
                GeoPath geoPath = (GeoPath)geoObject;
                if (geoPath.hasOneOrMorePoints()) {
                    bbox = geoPath.getBounds2D();
                }
            } else if (geoObject instanceof GeoPoint && shapeType == POINT_SHAPE_TYPE) {
                bbox = ((GeoPoint)geoObject).getBounds2D();
            } else if (geoObject instanceof GeoSet) {
                bbox = getExportedExtension((GeoSet)geoObject);
            }

            if (bbox != null) {
                minX = Math.min(bbox.getMinX(), minX);
                maxX = Math.max(bbox.getMaxX(), maxX);
                minY = Math.min(bbox.getMinY(), minY);
                maxY = Math.max(bbox.getMaxY(), maxY);
            }

        }
        return new Rectangle2D.Double(minX, minY, maxX - minX, maxY - minY);
    }

    /**
     * Writes a point to a stream.
     */
    private void writePoint(MixedEndianDataOutputStream mos, GeoPoint geoPoint)
    throws IOException {
        this.writeRecordHeader(mos, 20);
        final double x = geoPoint.getX();
        final double y = geoPoint.getY();
        mos.writeLittleEndianInt(POINT_SHAPE_TYPE);  // shape type
        mos.writeLittleEndianDouble(x);     // x coordinate
        mos.writeLittleEndianDouble(y);     // y coordinate
    }
    
    /**
     * Writes a path to a stream.
     */
    private void writePolyline(MixedEndianDataOutputStream mos, GeoPath geoPath)
    throws IOException {

        geoPath = geoPath.flatten(flatness, (GeoPath)null);

        double[] coordinates = new double[6];

        Rectangle2D bbox = geoPath.getBounds2D();
        final double xmin = bbox.getMinX();
        final double xmax = bbox.getMaxX();
        final double ymin = bbox.getMinY();
        final double ymax = bbox.getMaxY();
        
        ByteArrayOutputStream bos = new ByteArrayOutputStream();
        LittleEndianOutputStream los = new LittleEndianOutputStream(bos);
        los.writeInt(this.shapeType);  // polyline or polygon
        los.writeDouble(xmin);              // xmin
        los.writeDouble(ymin);              // ymin
        los.writeDouble(xmax);              // xmax
        los.writeDouble(ymax);              // ymax
        
        // count the number of non-connected paths
        int partsCount = 0;
        PathIterator pi = geoPath.getPathIterator(null);
        do {
            if (pi.currentSegment(coordinates) == PathIterator.SEG_MOVETO) {
                ++partsCount;
            }
            pi.next();
        } while (!pi.isDone());
        los.writeInt(partsCount);
        
        final int pointsCount = geoPath.getNumberOfSegments();
        los.writeInt(pointsCount);          // number of points
        
        // An array of length partsCount. Stores, for each PolyLine, the index of its
        // first point in the points array. Array indexes are with respect to 0.
        int pointsCounter = 0;
        pi = geoPath.getPathIterator(null);
        do {
            if (pi.currentSegment(coordinates) == PathIterator.SEG_MOVETO) {
                los.writeInt(pointsCounter);
            }
            ++pointsCounter;
            pi.next();
        } while (!pi.isDone());
        
        // write the path geometry
        pi = geoPath.getPathIterator(null, flatness);
        double lastX = Double.NaN;
        double lastY = Double.NaN;
        double lastMoveToX = Double.NaN;
        double lastMoveToY = Double.NaN;
        while (!pi.isDone()) {
            final int type = pi.currentSegment(coordinates);
            switch (type) {
                case PathIterator.SEG_MOVETO:
                    los.writeDouble(lastX = lastMoveToX = coordinates[0]);
                    los.writeDouble(lastY = lastMoveToY = coordinates[1]);
                    break;
                case PathIterator.SEG_LINETO:
                    los.writeDouble(lastX = coordinates[0]);
                    los.writeDouble(lastY = coordinates[1]);
                    break;
                case PathIterator.SEG_CLOSE:
                    if (!Double.isNaN(lastMoveToX) && !Double.isNaN(lastMoveToY)) {
                        los.writeDouble(lastX = lastMoveToX);
                        los.writeDouble(lastY = lastMoveToY);
                    }
                    break;
                default:
                    System.err.println("ShapeGeometryExporter: unsupported path segment");
            }
            pi.next();
        }
        
        bos.flush();
        bos.close(); // close the local ByteArrayOutputStream
        this.writeRecordHeader(mos, los.size());
        mos.write(bos.toByteArray());

    }
    
    /**
     * Returns the number of records written to the geometry file.
     * @return The number of features written so far.
     */
    public int getWrittenRecordCount() {
        return this.recordCounter-1;
    }
    
    /**
     * Writes a SHX file to the passed stream.
     * @param shxOutputStream The stream to write to. This stream is not closed.
     * @param geoSet The GeoSet that is exported.
     */
    public void writeSHXFile(OutputStream shxOutputStream, GeoSet geoSet)
    throws IOException {
        
        MixedEndianDataOutputStream mos
                = new MixedEndianDataOutputStream(shxOutputStream);
        
        // write the file header
        final int dataSize = (this.shxRecords.size() - 1 ) * 8;
        this.writeHeader(geoSet, mos, dataSize);
        
        // write the records
        final int recordsCount = this.shxRecords.size();
        for (int i = 0; i < recordsCount - 1; i++) {
            final int offset = ((Integer)this.shxRecords.get(i)).intValue();
            final int nextOffset = ((Integer)this.shxRecords.get(i+1)).intValue();
            final int contentLength = nextOffset - offset;
            mos.writeInt(offset / 2 + 50);  // + 50 for the file header
            mos.writeInt(contentLength / 2 - 4);
        }
        
        mos.flush();
    }

    public int getShapeType() {
        return shapeType;
    }

    /**
     * Set the type of shape file that will be generated. Valid values are 
     * POINT_SHAPE_TYPE, POLYLINE_SHAPE_TYPE, and POLYGON_SHAPE_TYPE.
     * The default value is POLYLINE_SHAPE_TYPE. use setShapeTypeFromFirstGeoObject
     * to automatically determine the type of shape file based on the first 
     * GeoObject in a GeoSet.
     */
    public void setShapeType(int shapeType) {
        if (shapeType != POINT_SHAPE_TYPE 
                && shapeType != POLYLINE_SHAPE_TYPE
                && shapeType != POLYGON_SHAPE_TYPE)
            throw new IllegalArgumentException("invalid shape type");
        
        this.shapeType = shapeType;
    }
    
    /**
     * Automatically determines the type of shape file that will be generated
     * based on the clas of the first GeoObject found in the passed GeoSet.
     */
    public void setShapeTypeFromFirstGeoObject(GeoSet geoSet) {
        
        GeoObject firstGeoObj = geoSet.getFirstGeoObject(GeoSet.class, true, false);
        if (firstGeoObj instanceof GeoPoint)
            shapeType = POINT_SHAPE_TYPE;
        else if (firstGeoObj instanceof GeoPath) {
            GeoPath geoPath = (GeoPath)firstGeoObj;
            shapeType = /*geoPath.isClosed() ? POLYGON_SHAPE_TYPE :  */POLYLINE_SHAPE_TYPE;
        }
        
    }
}
